The smallest possible structure
Programs, sections, artefacts, fields, options, assessments, the maturity model, reports — and the data people actually enter — are all the same shape: a thing, its properties, and how it connects to other things. Model that shape once and the schema stops needing migrations. New content is new rows, never new columns.
Three relationships do all the work. parent_id nests the tree (program → section → artefact → field → option, and a data row → its artefact). attr holds everything else a thing knows about itself. link carries the few cross-tree edges — a dashboard tile takes_from another stage, a tool slot uses_assessment, a person has_access to a node.
Watch real content map in
{{ ex.note }}
Why long, not wide
A new field on an artefact, a sixth maturity level, a whole new stage type — all rows. No ALTER TABLE, no deploy, no downtime. Authors change the schema from the builder UI.
A field definition and a filled-in cell are both nodes/attrs. One set of read, write, version and access paths serves the builder, the live document and every report.
Add valid_from / valid_to to each table and history is a query. Compare two published schemas, or replay what a report saw on a given date — without copies.
EAV trades a clean physical schema for query effort. Worth naming up front.
Reads need pivoting. Reconstructing a wide row means joining attr by key. Hide it behind one node_full view (or JSON aggregate) so app code never sees the EAV.
The DB won't type values. Either keep one text value + a typed-column variant, or store value_num / value_date / value_ref. The field's own type attr drives casting and validation in the app.
Indexing is targeted. Composite index on (node_id, key) and on parent_id covers nearly every access pattern; add a partial index per hot key if a report needs it.
Integrity lives in the app. "A field must have a type", "a select must have options" are validated against the kind on write — the same place the builder already enforces them.